package com.theapache64.stackzy.data.repo

import brut.androlib.meta.MetaInfo
import com.theapache64.stackzy.data.local.AnalysisReport
import com.theapache64.stackzy.data.local.GradleInfo
import com.theapache64.stackzy.data.local.LibResult
import com.theapache64.stackzy.data.local.Platform
import com.theapache64.stackzy.data.remote.Library
import com.theapache64.stackzy.data.util.AndroidVersionIdentifier
import com.theapache64.stackzy.data.util.StringUtils
import com.theapache64.stackzy.data.util.sizeInMb
import com.toxicbakery.logging.Arbor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.yaml.snakeyaml.Yaml
import org.yaml.snakeyaml.representer.Representer
import java.io.File
import java.nio.file.Path
import java.util.*
import javax.inject.Inject
import kotlin.io.path.Path
import kotlin.io.path.div
import kotlin.io.path.exists
import kotlin.io.path.readText
import kotlin.jvm.java

class ApkAnalyzerRepo @Inject constructor() {

    companion object {
        private val PHONEGAP_FILE_PATH_REGEX by lazy { "temp/smali(?:_classes\\d+)?/com(?:/adobe)?/phonegap".toRegex() }
        private val FLUTTER_FILE_PATH_REGEX by lazy { "smali/io/flutter/embedding/engine/FlutterJNI.smali".toRegex() }
        private val APP_LABEL_MANIFEST_REGEX by lazy { "<application.+?label=\"(.+?)\"".toRegex() }
        private val USER_PERMISSION_REGEX by lazy { "<uses-permission (?:android:)?name=\"(?<permission>.+?)\"/>".toRegex() }
        private val PACKAGE_FROM_DIR_REGEX by lazy { ".+\\/smali.*?\\/(.+)".toRegex() }
        private val OBF_REGEX by lazy { "^[a-z][a-z0-9]?\\.[0-9]$".toRegex() }
    }

    /**
     * To get final report
     */
    suspend fun analyze(
        packageName: String,
        apkFile: File,
        decompiledDir: File,
        allLibraries: List<Library>
    ): AnalysisReport = withContext(Dispatchers.IO) {
        val platform = getPlatform(decompiledDir)
        val libResult = getLibResult(packageName, platform, decompiledDir, allLibraries)
        AnalysisReport(
            appName = getAppName(decompiledDir) ?: packageName,
            packageName = packageName,
            platform = platform,
            libraries = libResult?.appLibs ?: emptySet(),
            untrackedLibraries = libResult?.untrackedLibs ?: emptySet(),
            apkSizeInMb = "%.2f".format(Locale.US, apkFile.sizeInMb).toFloat(),
            assetsDir = getAssetsDir(decompiledDir).takeIf { it.exists() },
            permissions = getPermissions(decompiledDir),
            gradleInfo = getGradleInfo(decompiledDir)
        )
    }


    /**
     * To get versionCode, versionName, minSdk and targetSdk from YAML file generated by apk-tool.
     */
    fun getGradleInfo(decompiledDir: File): GradleInfo {
        val yamlFile = File("${decompiledDir.absolutePath}${File.separator}apktool.yml")
        val yaml = Yaml(
            Representer().apply {
                propertyUtils.isSkipMissingProperties = true
            }
        )
        val yamlString = yamlFile.readText()
        val metaInfo : Any = yaml.load(yamlString)
        if(metaInfo is MetaInfo){
            // Backward compatible

            // Building gradle info
            return GradleInfo(
                versionCode = metaInfo.versionInfo?.versionCode,
                versionName = metaInfo.versionInfo?.versionName,
                minSdk = metaInfo.sdkInfo?.minSdkVersion?.let {
                    val androidVersionName = AndroidVersionIdentifier.getVersion(it)
                    GradleInfo.Sdk(it, androidVersionName)
                },
                targetSdk = metaInfo.sdkInfo?.targetSdkVersion?.let {
                    val androidVersionName = AndroidVersionIdentifier.getVersion(it)
                    GradleInfo.Sdk(it, androidVersionName)
                }
            )
        }else if(metaInfo is LinkedHashMap<*, *>) {
            // Handle LinkedHashMap format
            val versionInfo = metaInfo["versionInfo"] as? LinkedHashMap<*, *>
            val sdkInfo = metaInfo["sdkInfo"] as? LinkedHashMap<*, *>

            return GradleInfo(
                versionCode = versionInfo?.get("versionCode")?.toString()?.toIntOrNull(),
                versionName = versionInfo?.get("versionName") as? String,
                minSdk = (sdkInfo?.get("minSdkVersion") as? Int)?.let {
                    val androidVersionName = AndroidVersionIdentifier.getVersion(it)
                    GradleInfo.Sdk(it, androidVersionName)
                },
                targetSdk = (sdkInfo?.get("targetSdkVersion") as? Int)?.let {
                    val androidVersionName = AndroidVersionIdentifier.getVersion(it)
                    GradleInfo.Sdk(it, androidVersionName)
                }
            )
        }else{
            error("Unsupported meta info type: ${metaInfo::class.simpleName}. Expected MetaInfo or LinkedHashMap")
        }
    }

    /**
     * To get permissions used inside decompiled dir.
     */
    fun getPermissions(decompiledDir: File): Set<String> {
        val manifestFile = Path(decompiledDir.absolutePath) / "AndroidManifest.xml"
        return getPermissionsFromManifestFile(manifestFile)
    }

    fun getPermissionsFromManifestFile(manifestFile: Path): Set<String> {
        val permissions = mutableSetOf<String>()
        val manifestRead = manifestFile.readText()
        var matchResult = USER_PERMISSION_REGEX.find(manifestRead)
        while (matchResult != null) {
            permissions.add(matchResult.groupValues[1])
            matchResult = matchResult.next()
        }
        return permissions
    }

    /**
     * To get libraries used in the given decompiled app.
     *
     * Returns (untrackedLibs, usedLibs) in a Pair
     */
    fun getLibResult(
        packageName: String,
        platform: Platform,
        decompiledDir: File,
        allRemoteLibraries: List<Library>,
    ): LibResult? {
        return when (platform) {
            is Platform.NativeJava,
            is Platform.NativeKotlin -> {

                // Get all used libraries
                val libResult = getAppLibraries(packageName, decompiledDir, allRemoteLibraries)
                libResult.appLibs = mergeDep(libResult.appLibs.sortedBy { it.name }
                    .toSet()) // TODO: sort is quick fix here. should find better solution.

                libResult
            }

            else -> {
                // TODO : Support other platforms
                null
            }
        }
    }

    /**
     * To merge dependencies.
     */
    private fun mergeDep(
        appLibSet: Set<Library>
    ): MutableSet<Library> {
        val appLibraries = appLibSet.toMutableSet()
        val mergePairs = appLibSet
            .filter { it.replacementPackage != null }
            .map { Pair(it.replacementPackage, it.packageName) }
        for ((libToRemove, replacement) in mergePairs) {
            val hasDepLib = appLibraries.find { it.packageName.lowercase(Locale.getDefault()) == replacement } != null
            if (hasDepLib) {
                // remove that lib
                val library = appLibraries.find { it.packageName == libToRemove }
                if (library != null) {
                    appLibraries.removeIf { it.id == library.id }
                }
            }
        }

        return appLibraries
    }

    /**
     * To get app name from decompiled directory
     */
    fun getAppName(decompiledDir: File): String? {
        // Get label key from AndroidManifest.xml
        val label = getAppNameLabel(decompiledDir)
        if (label == null) {
            Arbor.w("Could not retrieve app name label")
            return null
        }
        var appName = if (label.contains("@string/")) {
            getStringXmlValue(decompiledDir, label)
        } else {
            label
        }
        if (appName == null || appName.startsWith("@string/")) {
            Arbor.w("Could not retrieve app name")
            return null
        }
        appName = StringUtils.removeApostrophe(appName)
        return appName
    }

    /**
     * To get the appropriate value for the given labelKey from string.xml in the decompiledDir.
     *
     * Returns null if not found
     */
    fun getStringXmlValue(decompiledDir: File, labelKey: String): String? {
        val stringXmlFile = Path(decompiledDir.absolutePath) / "res" / "values" / "strings.xml"
        val stringXmlContent = stringXmlFile.readText()
        val stringKey = labelKey.replace("@string/", "")
        val regEx = "<string name=\"$stringKey\">(.+?)</string>".toRegex()
        Arbor.d("@string regEx : '$regEx'")
        val value = regEx.find(stringXmlContent)?.groups?.get(1)?.value
        return if (value != null) {
            if (value.startsWith("@string/")) {
                // pointing to another string res, so go recursive
                getStringXmlValue(decompiledDir, value)
            } else {
                value
            }
        } else {
            null
        }
    }

    /**
     * To get `label`'s value from AndroidManifest.xml
     */
    fun getAppNameLabel(decompiledDir: File): String? {
        val manifestFile = Path(decompiledDir.absolutePath) / "AndroidManifest.xml"
        val manifestContent = manifestFile.readText()
        val match = APP_LABEL_MANIFEST_REGEX.find(manifestContent)
        return if (match != null && match.groupValues.isNotEmpty()) {
            match.groups[1]?.value
        } else {
            null
        }
    }

    /**
     * To get platform from given decompiled APK directory
     */
    fun getPlatform(decompiledDir: File): Platform {
        return when {
            isPhoneGap(decompiledDir) -> Platform.PhoneGap()
            isCordova(decompiledDir) -> Platform.Cordova()
            isXamarin(decompiledDir) -> Platform.Xamarin()
            isReactNative(decompiledDir) -> Platform.ReactNative()
            isFlutter(decompiledDir) -> Platform.Flutter()
            isWrittenKotlin(decompiledDir) -> Platform.NativeKotlin()
            isUnity(decompiledDir) -> Platform.Unity()
            else -> Platform.NativeJava()
        }
    }

    private fun isUnity(decompiledDir: File): Boolean {
        return decompiledDir.walk().find {
            it.name == "unity default resources"
        } != null
    }

    private fun isWrittenKotlin(decompiledDir: File): Boolean {
        return (Path(decompiledDir.absolutePath) / "kotlin").exists()
    }

    private fun isFlutter(decompiledDir: File): Boolean {
        return decompiledDir.walk()
            .find {
                it.name == "libflutter.so" ||
                        FLUTTER_FILE_PATH_REGEX.find(it.absolutePath) != null
            } != null
    }

    private fun isReactNative(decompiledDir: File): Boolean {
        return getAssetsDir(decompiledDir).listFiles()?.find { it.name == "index.android.bundle" } != null
    }

    private fun isXamarin(decompiledDir: File): Boolean {
        return decompiledDir.walk().find {
            it.name == "libxamarin-app.so" || it.name == "libmonodroid.so" || it.name == "Xamarin.Forms.Core.dll"
        } != null
    }

    private fun isCordova(decompiledDir: File): Boolean {
        val assetsDir = getAssetsDir(decompiledDir)
        val hasWWW = assetsDir.listFiles()?.find {
            it.name == "www"
        } != null

        if (hasWWW) {
            return (Path(assetsDir.absolutePath) / "www" / "cordova.js").exists()
        }

        return false
    }

    private fun isPhoneGap(decompiledDir: File): Boolean {
        val hasWWW = getAssetsDir(decompiledDir).listFiles()?.find {
            it.name == "www"
        } != null

        if (hasWWW) {
            return (Path(decompiledDir.absolutePath) / "smali" / "com" / "adobe" / "phonegap").exists() || decompiledDir.walk()
                .find { file ->
                    val filePath = file.absolutePath
                    isPhoneGapDirectory(filePath)
                } != null
        }
        return false
    }

    /**
     * To get asset directory from the given decompiledDir
     */
    private fun getAssetsDir(decompiledDir: File): File {
        //return File("${decompiledDir.absolutePath}/assets/")
        return (Path(decompiledDir.absolutePath) / "assets").toFile()
    }

    private fun isPhoneGapDirectory(filePath: String) = PHONEGAP_FILE_PATH_REGEX.find(filePath) != null

    /**
     * To get libraries used in the given decompiledDir (native app)
     */
    private fun getAppLibraries(
        packageName: String,
        decompiledDir: File,
        allLibraries: List<Library>
    ): LibResult {
        val appLibs = mutableSetOf<Library>()
        val untrackedLibs = mutableSetOf<String>()
        // loop through all smali_* directories until there's a file
        val endDirs = decompiledDir.listFiles()
            ?.filter { it.isDirectory && it.name.startsWith("smali") } // all smali dirs
            ?.flatMap { smaliDir ->
                smaliDir.walk().filter {
                    it.isDirectory &&
                            !it.absolutePath.contains(packageName.replace(".", File.separator))
                            && it.listFiles()?.find { x -> x.isDirectory } == null
                }
            }

        if (endDirs != null) {
            for (dir in endDirs) {
                val dirPackageName = parsePackageFromPath(dir)
                if (dirPackageName != null) {
                    val appLib = allLibraries.find { dirPackageName.contains(it.packageName) }
                    if (appLib != null) {
                        appLibs.add(appLib)
                    } else if (dirPackageName.contains(".") && !dirPackageName.matches(OBF_REGEX)) {
                        // to avoid obfuscated package names
                        untrackedLibs.add(dirPackageName)
                    }
                }
            }
        }

        // FIXME: Improve performace
        /*val nonLibSmaliFiles = decompiledDir
            .walk()
            .toList() // all files
            .filter { file ->
                if (file.extension != "smali") return@filter false
                var isLibFile = false
                for (library in allLibraries) {
                    if (file.absolutePath.contains(library.packageName.replace(".", File.separator))) {
                        isLibFile = true
                        break
                    }
                }
                !isLibFile
            } // All non lib smali files

        for (smaliFile in nonLibSmaliFiles) {
            val libPackageName = parsePackageFromPath(smaliFile)
            if (libPackageName != null) {
                untrackedLibs.add(libPackageName)
            }

            val fileContent = smaliFile.readText()
            for (library in allLibraries) {
                if (fileContent.contains(library.packageName.replace(".", "/"))) {
                    // has lib usage
                    appLibs.add(library)
                }
            }
        }*/

        return LibResult(appLibs, untrackedLibs)
    }

    private fun parsePackageFromPath(smaliFile: File): String? {
        val match = PACKAGE_FROM_DIR_REGEX.find(smaliFile.absolutePath)
        return match?.groupValues?.get(1)?.replace("/", ".")
    }
}


